Skip to content

feat: coalesce concurrent CIMD document fetches for the same client_id - #312

Merged
saucam merged 2 commits into
mainfrom
feat/cimd-coalesce-document-fetches
Sep 3, 2026
Merged

feat: coalesce concurrent CIMD document fetches for the same client_id#312
saucam merged 2 commits into
mainfrom
feat/cimd-coalesce-document-fetches

Conversation

@saucam

@saucam saucam commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

Collapses concurrent CIMD document fetches for the same client_id into a single outbound fetch.

The gap

The resolution cache only helps once a fetch has completed. Until then every arriving request is a miss and starts its own fetch — so N simultaneous first-time requests for one client_id meant N DNS resolutions, N TLS handshakes, and N × up-to-5s of request occupancy, for a document that is byte-identical every time.

That multiplier was free to the caller and required no credential: client resolution runs ahead of the PrincipalResolver chain (#285), so an unauthenticated request reaches the fetch. Concurrency was the only input needed.

ResolveClient now uses golang.org/x/sync/singleflight; the fetch/validate/synthesize/cache body moves to resolveUncached so the callback stays readable.

Two details that are easy to get wrong

  • The flight re-checks the cache before fetching. A fetch can complete between the outer miss and entering the flight; starting another would be duplicate work the flight itself cannot see.
  • Each waiter gets its own clone. singleflight hands the same value to every caller, and callers receive a mutable *domain.OAuthClient — the same reason cachedResult already returns a copy. Sharing one instance would let any waiter mutate what the others hold.

Scope — what this does not fix

Stated plainly because it's easy to over-read: this bounds duplicate concurrent work for one client_id. It does not bound distinct-URL abuse. A caller cycling unique paths gets a fresh flight each time, misses the cache, walks past negative caching (which is per-URL), and churns the 1000-entry cache's eviction.

Only an edge rate limit closes that. docs/cimd.md now says so explicitly rather than as a trailing aside, and separates what each control actually bounds: the caps bound one fetch, coalescing bounds duplicate concurrent work, allowed_domains bounds who can aim it.

Tracked on the deployer side in highflame-cloud#2358.

Verification

  • Mutation-checked. Bypassing the flight makes the new test report concurrent resolutions performed 8 fetches, want 1. A coalescing test that would pass anyway is worth nothing, so this matters more than the green run.
  • The test's handler blocks until every caller has arrived, so an implementation that serialises rather than coalesces also fails — otherwise the first fetch could finish and warm the cache before the others start, and the test would pass for the wrong reason.
  • Asserts waiters hold distinct *domain.OAuthClient values, and that mutating one caller's RedirectURIs doesn't change another's (catches a shallow clone).
  • Passes under -race. Full internal/... unit suites green. golangci-lint: 0 issues.

Context

Found while reviewing why CIMD is disabled in Highflame production. The two blockers were this and the missing edge rate limit; with both addressed, an open-ecosystem CIMD deployment is defensible.

🤖 Generated with Claude Code

The resolution cache only helps once a fetch has COMPLETED. Until then
every arriving request was a miss and started its own fetch, so N
simultaneous first-time requests for one client_id meant N DNS
resolutions, N TLS handshakes, and N x up-to-5s of request occupancy for
a document that is byte-identical every time.

That multiplier was free to the caller and needed no credential: client
resolution runs ahead of the PrincipalResolver chain (#285), so an
unauthenticated request reaches the fetch, and concurrency was the only
input required.

ResolveClient now collapses concurrent misses for the same client_id into
a single flight via golang.org/x/sync/singleflight; the rest wait on that
result. The fetch/validate/synthesize/cache body moves to
resolveUncached so the callback stays readable.

Two details that are easy to get wrong:

  - The flight re-checks the cache before fetching. A fetch may complete
    between the outer miss and entering the flight, and starting another
    one would be duplicate work the flight itself cannot see.
  - Each waiter gets its OWN clone. singleflight hands the same value to
    every caller, and callers receive a mutable *domain.OAuthClient --
    the same reason cachedResult already returns a copy. Sharing one
    instance would let any waiter mutate what the others hold.

Scope, stated plainly because it is easy to over-read: this bounds
DUPLICATE CONCURRENT work for one client_id. It does NOT bound
distinct-URL abuse -- a caller cycling unique paths gets a fresh flight
each time, misses the cache, walks past negative caching, and churns
eviction. Only an edge rate limit closes that, which docs/cimd.md now
says explicitly rather than as an aside.

The test is mutation-checked: bypassing the flight makes it report 8
fetches instead of 1. Its handler blocks until every caller has arrived,
so an implementation that serialises rather than coalesces fails too. It
also asserts the waiters hold distinct clients and that mutating one
caller's RedirectURIs does not change another's. Passes under -race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgolang/​golang.org/​x/​sync@​v0.22.099100100100100

View full report

@adeinega

adeinega commented Sep 3, 2026

Copy link
Copy Markdown

As a couple of side notes... the database, or shared distributed caches are the safe places to store the CIMD Metadata. The reasons is simple - you might have multiple running instances of ZeroID. cache map[string]cimdCacheEntry is going to work well only within one (running) instance.

The CIMD specification, as of the time this comment was written, is currently in its second draft, and my guess is we're going to see changes in it.

@saucam

saucam commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Both good calls, thank you — and the first one changed how I'd frame this PR.

On the per-instance cache

You're right, and it's worth putting a number on it: prod AuthN autoscales 2–6 replicas, so cache map[string]cimdCacheEntry is 2–6 independent caches. The singleflight here only collapses concurrent fetches within one process; across replicas it does nothing, so the fan-out you're describing survives this PR entirely. I'll say that in the PR description rather than leave it implied.

One distinction I'd draw before we reach for shared storage, because I think it splits the problem in two:

A shared cache fixes fan-out and consistency. It does not fix staleness. Redis or Postgres would make all replicas consistently stale rather than inconsistently stale — nothing about shared storage makes a cached document fresher. The security-relevant property is revocation latency: a client that pulls a compromised redirect_uri out of its document wants that to take effect promptly. The only levers on that are the TTL and honouring the document's Cache-Control (ZeroID already takes the shorter of the two, floored at 60s).

So I'd treat them as separate decisions:

  • Consistency across replicas → shared cache. Real property, worth wanting.
  • Revocation latency → TTL. Independent of where the cache lives.

Today the inconsistency has a concrete edge: two replicas can serve different versions of one document for up to an hour, so a client's own remediation may or may not have taken effect depending on which pod they land on — and they can't tell which. That's the part I find least comfortable, and notably it's the part shared storage doesn't fix.

On medium, if we do go shared

I'd argue Redis over the database, fairly strongly. CIMD's defining property is that nothing is persisted — the synthesized client carries registration_source: cimd and never reaches a table. A DB-backed cache reintroduces a row per client_id and a write per resolution, which is the DCR bloat CIMD exists to remove, and puts a write on the /oauth2/authorize hot path. It would also recreate a registry-first shadowing hazard we just had to fix downstream in Studio, where a persisted row silently overrode the published document.

Redis is a much better fit — AuthN already has it for backchannel, quarantine and revocation, so no new dependency. One thing I'd want decided deliberately rather than as a side effect, though: what's being cached is redirect_uris, the load-bearing anti-impersonation control. An in-process cache is only poisonable by compromising the process; a shared cache is poisonable by anything that can write to Redis, and whoever writes it chooses where authorization codes get delivered. That's a real widening of Redis's blast radius. Solvable — signed entries, or a considered "Redis is in the TCB" decision — but I don't think it should ride in as an implementation detail of a caching change.

My honest read for right now: at 2–6 replicas the fan-out is a handful of extra fetches per hour, and the abuse case it amplifies is bounded by edge rate limiting on /oauth2/authorize (tracked in highflame-cloud#2358), which holds regardless of replica count. So I'd land this as the in-process improvement it is, and take shared caching as its own change with the poisoning question answered up front. Happy to be argued out of that ordering if you think the inconsistency window is the more pressing half.

On the draft moving

Agreed, and this PR now records where we stand — I've added a "Specification revision and deviations" section to docs/cimd.md covering the revision implemented against, the two places we're deliberately stricter than the draft, and what we've intentionally not built.

The strictness is the part your comment made me want written down. Rejecting a query string (draft says only SHOULD NOT) and requiring client_name (draft merely RECOMMENDS) are both defensible, but "stricter than the spec" ages badly in exactly the way you're describing: a later draft can bless something we refuse, and then we're rejecting valid clients for reasons nobody remembers choosing.

The failure mode I'd flag hardest is the discovery field name. If client_id_metadata_document_supported is renamed in a later draft, our advertisement silently stops being understood, clients fall back to DCR, and everything keeps working — no error anywhere. Our tests wouldn't catch it either, since they assert we emit that field; they'd stay green while no client could see it. We hit precisely that shape last week in a different component. The only real guard is tracking the draft, so the doc now says so explicitly rather than relying on someone remembering.

Two things that already make drift survivable, which I've also written down: DCR is retained as a deliberate fallback, and registry-first resolution means any client caught by a spec change can be pinned by registering it, overriding whatever its document says.

…cess cache

Follows review on this PR. Two gaps in docs/cimd.md that the reviewer's
questions exposed: nothing recorded WHICH draft revision the
implementation targets, and nothing warned that the resolution cache is
per process.

Adds a "Specification revision and deviations" section covering:

  - the revision implemented against
    (draft-ietf-oauth-client-id-metadata-document-02, WG-adopted Oct
    2025), stated plainly as a draft that will change

  - the two places ZeroID is deliberately STRICTER than the draft --
    rejecting a query string (draft: SHOULD NOT) and requiring
    client_name (draft: RECOMMENDED) -- with the reasoning for each and
    an explicit note to revisit both on every draft bump. Strictness is
    the part that ages badly: a later revision can bless what we refuse,
    and then we reject valid clients for a reason nobody remembers
    choosing.

  - what is deliberately NOT built (confidential clients via
    private_key_jwt + jwks_uri, and software_statement), which are also
    the areas the draft is likeliest to move in

  - the change most likely to break SILENTLY: a rename of
    client_id_metadata_document_supported. Nothing errors -- the server
    advertises a key clients no longer look for, they fall back to DCR,
    and the flow keeps working via the row-per-client path CIMD exists
    to remove. Tests do not help, because they assert the server EMITS
    the field; they stay green while no client can see it. Written down
    because tracking the draft is the only guard.

  - what makes drift survivable: DCR retained as a fallback, and
    registry-first resolution as a pinning mechanism for any single
    client caught by a spec change.

Adds a deployment note that the cache is per process, since this PR's
singleflight coalesces within a process and not across replicas. Names
both consequences -- fan-out of up to N fetches per document per TTL
with per-replica negative caching, and non-uniform staleness where two
replicas serve different versions of one document for up to the TTL.

States the distinction that matters for anyone reaching for Redis: a
shared cache fixes fan-out and makes replicas CONSISTENTLY stale, but it
does not make them FRESHER. Revocation latency is governed by the TTL
and the document's Cache-Control, wherever the cache lives. Also notes
that a shared cache holds redirect_uris -- the primary anti-impersonation
control -- so write access to it is equivalent to choosing where
authorization codes are delivered.

"Limitations / future work" now links to those sections instead of
restating them, so the two cannot drift apart. All three intra-doc
anchors verified to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saucam

saucam commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Pushed e71c8fd with the docs, plus one correction to this PR's own framing.

The PR description overstated what this fixes. It said coalescing bounds duplicate concurrent work, which is true, but it did not say that the bound is per process. With prod AuthN autoscaling 2–6 replicas, the fan-out you identified survives this change entirely — the singleflight collapses concurrent fetches within one process and does nothing across them. docs/cimd.md now says that in a deployment note rather than leaving it for a reader to infer.

The new Specification revision and deviations section records:

  • the revision implemented against (draft-...-02, WG-adopted Oct 2025), stated as a draft that will change
  • the two places we are deliberately stricter — rejecting a query string (draft: SHOULD NOT) and requiring client_name (draft: RECOMMENDED) — each with its reasoning and an explicit revisit on every draft bump
  • what is deliberately not built (confidential clients, software_statement), which is also where the draft is likeliest to move
  • the silent-failure case: a rename of client_id_metadata_document_supported produces no error anywhere, clients quietly fall back to DCR, and our tests stay green because they assert we emit the field
  • what makes drift survivable — DCR retained as fallback, and registry-first resolution as a per-client pinning mechanism

Limitations / future work now links to those sections instead of restating them, so the two cannot drift apart. All intra-doc anchors verified.

I have deliberately not changed the caching design in this PR — it stays the in-process improvement it is, with the limitation documented. If you would rather the inconsistency window be closed before this lands, say so and I will pick up shared caching as its own change with the poisoning question settled first; I do not think it should ride in as a caching detail either way.

@saucam
saucam merged commit fac29e2 into main Sep 3, 2026
11 checks passed
saucam added a commit that referenced this pull request Sep 5, 2026
Addresses the Oracle review on #284.

## The vetted bypass depended on an untested call

Once `cimd.allowed_domains` names a host, `refusesRedirectTo` stops refusing
redirects for self-asserted clients deployment-wide, without re-checking the
individual client's redirect host. That is only sound because resolution has
already refused any document declaring an off-list https `redirect_uri` --
vetting the publication host alone does not cover it, since a document hosted
on an allow-listed domain can declare `redirect_uris` pointing anywhere.

`TestRedirectHostsAllowed` covered the predicate in isolation. Nothing covered
the wiring, and the wiring is what can vanish: rebasing this branch onto the
#312 singleflight refactor moved the fetch into `resolveUncached` and severed
that call outright. It surfaced only because it happened to be a compile error.
A refactor that left a same-named host variable in scope would have kept
compiling while vetting the wrong host, and no test would have failed.

`TestCIMDResolveClient_OffListRedirectURIRefused` drives `ResolveClient` end to
end: a document served BY the allow-listed host declaring an off-list https
redirect_uri must be refused, plus a control proving an allow-listed redirect
host still resolves. Verified by mutation -- removing the `redirectHostsAllowed`
call makes it fail with its own diagnostic.

## Two loopback classifications were unpinned

`RedirectDeliversLocally` is the only gate for an unvetted self-asserted client,
so its edge cases are load-bearing:

- Userinfo confusion -- a loopback literal in the userinfo position in front of
  a hostile authority. The real host is the hostile one. Already correct via
  `url.Parse().Hostname()`; now pinned so nobody switches to matching the raw
  URI, where the loopback prefix reads as trustworthy.
- `http://0.0.0.0/cb` -- browsers commonly normalise this to loopback. We
  classify it remote, which fails closed; pinned so widening it stays a
  deliberate security decision.

Oracle also asked for IPv6-literal and localhost-vs-literal coverage; both were
already in the table.
saucam added a commit that referenced this pull request Sep 5, 2026
…ns vets remote ones; docs: name both ways to supply the browser leg (#284)

* docs: name both ways to supply the browser leg, not just the cookie resolver

docs/cimd.md said the browser leg "needs a GET-capable PrincipalResolver, which
ZeroID does not ship" and that the deployer "must register one that reads a
session cookie". True as far as it goes, but it presents the harder route as the
only route — and it is not the route Highflame itself takes.

A deployer can instead front the browser leg ABOVE ZeroID: own the redirect,
authenticate the human however they already do, then POST to /oauth2/authorize
with a credential a form-based resolver reads — an RFC 7523 assertion signed by
that surface, verified against its published JWKS. The browser never reaches the
endpoint, so no GET-capable resolver is needed AND the CSRF exposure documented
below does not arise: the caller is a server, not a navigation.

That matters because the CSRF obligations are the expensive part of route 1, and
a reader who thinks route 1 is mandatory takes them on unnecessarily. Highflame's
own deployment is route 2 — Studio authenticates, mints an assertion and POSTs;
AuthN's assertion resolver verifies it and ZeroID mints the code.

Either way ZeroID stays the engine: it validates the CIMD document, enforces the
redirect_uri allow-list, and issues the code. Route 1 is for deployers with no
such surface of their own.

Docs only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: route 2 relocates CSRF to the fronting surface; qualify the GET-resolver premise

Review follow-up (PR #284), both threads:

- The bold premise said the browser leg needs a GET-capable resolver,
  full stop, while route 2 two paragraphs later needs none. It now says
  DIRECT browser access at /oauth2/authorize needs one, and the list is
  framed as the two ways to connect the browser leg. Same qualification
  on the 'ZeroID cannot detect this' paragraph: form-based-only is a
  misconfiguration only when no fronting surface exists.

- Route 2 no longer claims the CSRF exposure 'does not arise'. Moving
  the final hop to a server-to-server POST removes navigation
  reachability of /oauth2/authorize itself, but an attacker can still
  navigate a victim to the fronting surface with an attacker-published
  client_id — so the CSRF-protected consent interaction must happen at
  that surface before the assertion is minted. Said so, explicitly.

Also scopes the 'Highflame takes route 2' claim: MCP clients still go
through Studio's local code-minting today; highflame-studio#1392 brings
them onto this path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: route 1 gets ZeroID's login redirect, but not for a CIMD client

#285 landed ErrPrincipalInteractionRequired + SetInteractiveLoginURL, so a
cookie resolver no longer has to hand-roll the 302 to its login screen. It
also refuses that redirect for a self-asserted (CIMD) client, which is the
half that matters in this doc: on route 1 a CIMD authorization request only
succeeds for a user who already has a session.

* fix: cimd.allowed_domains actually restores redirects to a CIMD client

three places document and no code implements. failAuthorize and
redirectToInteractiveLogin both gated on client.SelfAsserted() alone, and
RegistrationSource is set to "cimd" unconditionally at synthesis, so setting
cimd.allowed_domains changed nothing. The handler never even received the
allow-list — API carried only cimdEnabled.

Both gates now go through one predicate, refusesRedirectTo, so the error
redirect and the interactive-login redirect cannot drift apart: they answer
the same question about the same client. Server.NewServer feeds it
AllowedDomainCount() > 0 — the EFFECTIVE list, for the same reason the
startup log uses it, since allowed_domains: [""] has length 1 and vets
nothing.

This is what makes the browser leg completable for an MCP CIMD client. An
unvetted one is never sent to the login surface, so a user with no session
cannot establish one and the flow cannot finish at all — the allow-list is
the switch, and it was wired to nothing. The empty-allowlist startup warning
now names that consequence too.

Also ignores .gstack/, which is per-session browser audit output.

* fix: hold CIMD redirect destinations to the allow-list too, not just publishers

Review of the previous commit found the premise it rests on is not actually
established. refusesRedirectTo reads "an allow-listed publisher is a vetted
party, so redirects apply again" — but nothing tied redirect_uris to the
allow-list, or even to the client_id host. synthesizeCIMDClient checks scheme
rules only.

So on any host where more than one party can publish a path — user content, a
raw-file CDN, a broadly writable bucket, a shared internal app host, the
config's own apps.acme.dev example — allow-listing it re-opened exactly what
https://evil.example/cb, and an unauthenticated GET /oauth2/authorize 302s to
evil.example. Worse than before the allow-list, in fact, because
redirectToInteractiveLogin now walks a victim through the real login page
first, so the code lands at the attacker after a genuine sign-in.

redirectHostsAllowed closes it: an https redirect_uri must be on the
client_id's own host or on the allow-list. Loopback and private-use schemes
stay exempt — they deliver to the caller's own machine, which is the native
and MCP client shape. In open mode domainAllowed admits everything, so this
is a no-op there, correctly: open mode refuses those redirects outright.

Also from review:

- refusesRedirectTo refused to answer for a nil client by returning false.
  Folded the nil case in and dropped the duplicated check at both call sites,
  which is what "one predicate so they cannot drift" was supposed to mean.
- CIMDConfig.AllowedDomains' godoc and zeroid.yaml still described the field
  as a fetch/SSRF lever with empty as a fine default. It now also decides
  whether a browser CIMD client can sign a user in at all, and both say so —
  along with the new obligation that listing a host asserts you vet who
  publishes there.
- docs/cimd.md's "Errors are not redirected to a CIMD client" heading and a
  spec cross-reference pointing at §12.6 (Caching) instead of §12.7.

* feat: a loopback CIMD callback is redirected to; the carve-out is about reach

The §4.1.2.1 carve-out refuses to redirect to a self-asserted client because
its redirect_uris are attacker-CHOSEN, which would make the endpoint "an
unauthenticated redirector with the AS's own origin as the first hop." That
is a claim about a REMOTE destination, and it was being applied to every
destination.

A 302 to 127.0.0.1 has no remote hop. The code lands on the machine the user
is sitting at, and an attacker who can listen there already has local code
execution — the same reasoning RFC 8252 §7.3 uses to accept loopback
callbacks from clients nobody registered. CIMD does not weaken it.

This is not a corner case, it is the MCP case. A CIMD client_id names the
client VENDOR's domain, and the canonical document in docs/cimd.md — an
ordinary desktop/CLI MCP client — lists loopback callbacks and nothing else.
So the carve-out was costing the entire browser leg for the dominant client
shape while preventing nothing, and cimd.allowed_domains was being asked to
buy back something the loopback property already gives for free.

refusesRedirectTo now asks whether the destination can reach a third party
the client chose: local delivery proceeds, remote https stays subject to
provenance and the allow-list. service.RedirectDeliversLocally holds the
judgement, next to the URI rules it belongs with; exact-match loopback means
127.0.0.1.evil.com is remote, which is tested.

Consequence worth stating: a desktop MCP client now completes the browser leg
with no allowlist configured at all. Only clients with real https callbacks
need cimd.allowed_domains, and the config surface, docs and spec §12.5 say so
rather than the blanket MUST they carried an hour ago.

* fix: pin the CIMD redirect invariants Oracle flagged as prose-only

Addresses the Oracle review on #284.

## The vetted bypass depended on an untested call

Once `cimd.allowed_domains` names a host, `refusesRedirectTo` stops refusing
redirects for self-asserted clients deployment-wide, without re-checking the
individual client's redirect host. That is only sound because resolution has
already refused any document declaring an off-list https `redirect_uri` --
vetting the publication host alone does not cover it, since a document hosted
on an allow-listed domain can declare `redirect_uris` pointing anywhere.

`TestRedirectHostsAllowed` covered the predicate in isolation. Nothing covered
the wiring, and the wiring is what can vanish: rebasing this branch onto the
#312 singleflight refactor moved the fetch into `resolveUncached` and severed
that call outright. It surfaced only because it happened to be a compile error.
A refactor that left a same-named host variable in scope would have kept
compiling while vetting the wrong host, and no test would have failed.

`TestCIMDResolveClient_OffListRedirectURIRefused` drives `ResolveClient` end to
end: a document served BY the allow-listed host declaring an off-list https
redirect_uri must be refused, plus a control proving an allow-listed redirect
host still resolves. Verified by mutation -- removing the `redirectHostsAllowed`
call makes it fail with its own diagnostic.

## Two loopback classifications were unpinned

`RedirectDeliversLocally` is the only gate for an unvetted self-asserted client,
so its edge cases are load-bearing:

- Userinfo confusion -- a loopback literal in the userinfo position in front of
  a hostile authority. The real host is the hostile one. Already correct via
  `url.Parse().Hostname()`; now pinned so nobody switches to matching the raw
  URI, where the loopback prefix reads as trustworthy.
- `http://0.0.0.0/cb` -- browsers commonly normalise this to loopback. We
  classify it remote, which fails closed; pinned so widening it stays a
  deliberate security decision.

Oracle also asked for IPv6-literal and localhost-vs-literal coverage; both were
already in the table.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash Datta <yd2590@columbia.edu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants